● Zion Boggan
repos/Stepping App/apps/web/src/app/city/[slug]/event/[id]/page.tsx
zionboggan.com ↗
460 lines · tsx
History for this file →
1
import Link from "next/link";
2
import { SiteFooter } from "@/components/site-footer";
3
import { PhotoGallery } from "@/components/photo-gallery";
4
import { notFound, redirect } from "next/navigation";
5
import { revalidatePath } from "next/cache";
6
import { serverCaller } from "@/lib/trpc/server";
7
import { saveUpload } from "@/lib/uploads";
8
import { cityTimeZone, fmtEventTime } from "@/lib/format";
9
 
10
export const dynamic = "force-dynamic";
11
 
12
const TYPE_LABEL: Record<string, string> = {
13
  set: "Set",
14
  weekender: "Weekender",
15
  class: "Class",
16
  workshop: "Workshop",
17
  competition: "Competition",
18
  special: "Special event",
19
  social: "Social",
20
};
21
 
22
function money(cents: number) {
23
  return cents === 0 ? "Free" : `$${(cents / 100).toFixed(2)}`;
24
}
25
 
26
 
27
function stamp(d: Date | null) {
28
  if (!d) return "";
29
  return new Date(d)
30
    .toLocaleString("en-US", {
31
      month: "short",
32
      day: "numeric",
33
      hour: "numeric",
34
      minute: "2-digit",
35
    })
36
    .toUpperCase();
37
}
38
 
39
export default async function EventPage({
40
  params,
41
}: {
42
  params: Promise<{ slug: string; id: string }>;
43
}) {
44
  const { slug, id } = await params;
45
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
46
    notFound();
47
  }
48
  const caller = serverCaller();
49
  const city = await caller.city.bySlug({ slug });
50
  if (!city) notFound();
51
 
52
  const event = await caller.event.byId({ id });
53
  if (!event) notFound();
54
 
55
  const tz = cityTimeZone(city.state);
56
 
57
  const ticketTypes = await caller.ticketType.list({ eventId: id });
58
  const featured =
59
    event.isPromoted &&
60
    (!event.boostedUntil || new Date(event.boostedUntil) > new Date());
61
 
62
  const comments = await caller.comment.list({
63
    subjectType: "event",
64
    subjectId: id,
65
  });
66
 
67
  const photos = await caller.eventPhoto.list({ eventId: id });
68
  const eventVendors = await caller.vendor.listForEvent({
69
    cityId: city.id,
70
    eventId: id,
71
  });
72
 
73
  async function reserve(formData: FormData) {
74
    "use server";
75
    const ticketTypeId = String(formData.get("ticketTypeId") ?? "");
76
    const quantity = Math.max(1, Math.min(10, Number(formData.get("quantity") ?? 1)));
77
    if (!ticketTypeId) return;
78
    await serverCaller().ticket.reserve({ ticketTypeId, quantity });
79
    redirect("/tickets");
80
  }
81
 
82
  async function addTicketType(formData: FormData) {
83
    "use server";
84
    const name = String(formData.get("name") ?? "").trim();
85
    const dollars = Number(formData.get("price") ?? 0);
86
    const capRaw = String(formData.get("capacity") ?? "").trim();
87
    if (!name) return;
88
    await serverCaller().ticketType.create({
89
      eventId: id,
90
      name,
91
      priceCents: Math.max(0, Math.round(dollars * 100)),
92
      capacity: capRaw ? Math.max(1, Number(capRaw)) : undefined,
93
    });
94
    revalidatePath(`/city/${slug}/event/${id}`);
95
  }
96
 
97
  async function boost() {
98
    "use server";
99
    await serverCaller().event.boost({ id, days: 14 });
100
    revalidatePath(`/city/${slug}/event/${id}`);
101
  }
102
 
103
  async function addComment(formData: FormData) {
104
    "use server";
105
    const body = String(formData.get("body") ?? "").trim();
106
    if (!body) return;
107
    await serverCaller().comment.create({
108
      subjectType: "event",
109
      subjectId: id,
110
      body,
111
    });
112
    revalidatePath(`/city/${slug}/event/${id}`);
113
  }
114
 
115
  async function submitPhoto(formData: FormData) {
116
    "use server";
117
    const file = formData.get("photo");
118
    const caption = String(formData.get("caption") ?? "").trim();
119
    if (!(file instanceof File) || file.size === 0) return;
120
    let url: string;
121
    try {
122
      url = await saveUpload(file, "image");
123
    } catch {
124
      return;
125
    }
126
    await serverCaller().eventPhoto.submit({
127
      eventId: id,
128
      url,
129
      caption: caption || undefined,
130
    });
131
    revalidatePath(`/city/${slug}/event/${id}`);
132
  }
133
 
134
  async function reportEvent(formData: FormData) {
135
    "use server";
136
    const reason = String(formData.get("reason") ?? "").trim();
137
    await serverCaller().flag.create({
138
      subjectType: "event",
139
      subjectId: id,
140
      reason: reason || undefined,
141
    });
142
    revalidatePath(`/city/${slug}/event/${id}`);
143
  }
144
 
145
  return (
146
    <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
147
      <Link href={`/city/${slug}`} className="kicker hover:text-gold">
148
        ← {city.name}
149
      </Link>
150
 
151
      <p className="kicker mt-10 text-ash">
152
        {(TYPE_LABEL[event.type] ?? event.type).toUpperCase()} · {fmtEventTime(event.startsAt, tz)} ·{" "}
153
        {city.name}
154
      </p>
155
      <h1 className="display mt-3 text-6xl sm:text-7xl">{event.title}</h1>
156
 
157
      {event.description ? (
158
        <p className="serif mt-6 max-w-xl text-xl text-bone">
159
          {event.description}
160
        </p>
161
      ) : null}
162
 
163
      {event.flyerUrl ? (
164
        <figure className="mt-10">
165
          <img
166
            src={event.flyerUrl}
167
            alt={`${event.title} flyer`}
168
            className="w-full border border-[var(--line)]"
169
          />
170
        </figure>
171
      ) : null}
172
 
173
      {event.externalUrl || event.ticketUrl || event.programUrl ? (
174
        <div className="mt-7 flex flex-wrap gap-4">
175
          {event.ticketUrl ? (
176
            <a
177
              href={event.ticketUrl}
178
              target="_blank"
179
              rel="noopener noreferrer"
180
              className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
181
            >
182
              Tickets →
183
            </a>
184
          ) : null}
185
          {event.externalUrl ? (
186
            <a
187
              href={event.externalUrl}
188
              target="_blank"
189
              rel="noopener noreferrer"
190
              className="kicker border border-[var(--line)] px-6 py-3 text-gold hover:border-gold hover:text-gold-bright"
191
            >
192
              Details →
193
            </a>
194
          ) : null}
195
          {event.programUrl ? (
196
            <a
197
              href={event.programUrl}
198
              target="_blank"
199
              rel="noopener noreferrer"
200
              className="kicker border border-[var(--line)] px-6 py-3 text-gold hover:border-gold hover:text-gold-bright"
201
            >
202
              Download the program (PDF) →
203
            </a>
204
          ) : null}
205
        </div>
206
      ) : null}
207
 
208
      {}
209
      <section className="mt-14 border-t border-[var(--line)] pt-10">
210
        <div className="flex items-end justify-between">
211
          <h2 className="display text-3xl">Tickets</h2>
212
          {featured ? (
213
            <span className="kicker text-gold">★ Featured</span>
214
          ) : (
215
            <form action={boost}>
216
              <button type="submit" className="kicker text-ash hover:text-gold">
217
                Boost this event →
218
              </button>
219
            </form>
220
          )}
221
        </div>
222
 
223
        {ticketTypes.length === 0 ? (
224
          <p className="serif mt-6 text-xl text-ash">
225
            No tickets yet. Add a ticket type below, or it&apos;s free at the door.
226
          </p>
227
        ) : (
228
          <ul className="mt-6 flex flex-col gap-3">
229
            {ticketTypes.map((t) => {
230
              const left = t.capacity != null ? t.capacity - t.sold : null;
231
              const soldOut = left != null && left <= 0;
232
              return (
233
                <li
234
                  key={t.id}
235
                  className="flex items-center gap-4 border border-[var(--line)] px-5 py-4"
236
                >
237
                  <div className="flex-1">
238
                    <p className="display text-2xl">{t.name}</p>
239
                    <p className="kicker mt-1 text-ash">
240
                      {money(t.priceCents)}
241
                      {left != null ? ` · ${left} LEFT` : ""}
242
                    </p>
243
                  </div>
244
                  {soldOut ? (
245
                    <span className="kicker text-ash">Sold out</span>
246
                  ) : (
247
                    <form action={reserve} className="flex items-center gap-2">
248
                      <input type="hidden" name="ticketTypeId" value={t.id} />
249
                      <input
250
                        type="number"
251
                        name="quantity"
252
                        defaultValue={1}
253
                        min={1}
254
                        max={Math.min(10, left ?? 10)}
255
                        className="kicker w-16 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
256
                      />
257
                      <button
258
                        type="submit"
259
                        className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright"
260
                      >
261
                        Reserve
262
                      </button>
263
                    </form>
264
                  )}
265
                </li>
266
              );
267
            })}
268
          </ul>
269
        )}
270
 
271
        {}
272
        <form action={addTicketType} className="mt-6 flex flex-wrap items-end gap-3">
273
          <input
274
            name="name"
275
            required
276
            placeholder="Ticket name (e.g. General, VIP)"
277
            className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
278
          />
279
          <input
280
            type="number"
281
            name="price"
282
            step="1"
283
            min="0"
284
            placeholder="Price $"
285
            className="kicker w-24 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold"
286
          />
287
          <input
288
            type="number"
289
            name="capacity"
290
            min="1"
291
            placeholder="Qty (opt)"
292
            className="kicker w-24 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold"
293
          />
294
          <button
295
            type="submit"
296
            className="kicker border border-[var(--line)] px-5 py-2 text-gold hover:border-gold"
297
          >
298
            Add ticket
299
          </button>
300
        </form>
301
        <p className="kicker mt-3 text-ash">
302
          Reserving is free while we turn on payments.
303
        </p>
304
      </section>
305
 
306
      {}
307
      <section className="mt-16 border-t border-[var(--line)] pt-10">
308
        <div className="flex items-end justify-between">
309
          <h2 className="display text-3xl">Photos</h2>
310
          <span className="kicker text-ash">
311
            {String(photos.length).padStart(2, "0")} in the album
312
          </span>
313
        </div>
314
 
315
        {photos.length === 0 ? (
316
          <p className="serif mt-6 text-xl text-ash">
317
            No photos in the album yet. Were you there? Send yours in below.
318
          </p>
319
        ) : (
320
          <div className="mt-6">
321
            <PhotoGallery photos={photos} />
322
          </div>
323
        )}
324
 
325
        <form action={submitPhoto} className="mt-8 flex flex-wrap items-end gap-3">
326
          <label className="flex flex-1 flex-col gap-2">
327
            <span className="kicker text-ash">Send in your photos</span>
328
            <input
329
              type="file"
330
              name="photo"
331
              required
332
              accept="image/jpeg,image/png,image/webp"
333
              className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none file:mr-3 file:border-0 file:bg-gold file:px-3 file:py-1 file:text-stage"
334
            />
335
          </label>
336
          <input
337
            name="caption"
338
            maxLength={200}
339
            placeholder="Caption (opt)"
340
            className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
341
          />
342
          <button
343
            type="submit"
344
            className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright"
345
          >
346
            Submit
347
          </button>
348
        </form>
349
        <p className="kicker mt-3 text-ash">
350
          JPEG, PNG, or WebP up to 8MB. Every photo is reviewed before it appears.
351
        </p>
352
      </section>
353
 
354
      {}
355
      {eventVendors.length > 0 ? (
356
        <section className="mt-16 border-t border-[var(--line)] pt-10">
357
          <div className="flex items-end justify-between">
358
            <h2 className="display text-3xl">Vendors</h2>
359
            <span className="kicker text-ash">On site at this event</span>
360
          </div>
361
          <ul className="mt-6 flex flex-col gap-3">
362
            {eventVendors.map((v) => (
363
              <li
364
                key={v.id}
365
                className="flex flex-wrap items-center gap-4 border border-[var(--line)] px-5 py-4"
366
              >
367
                <div className="flex-1">
368
                  <p className="display text-2xl">{v.name}</p>
369
                  <p className="kicker mt-1 text-ash">
370
                    {v.category ? v.category.toUpperCase() : "VENDOR"}
371
                  </p>
372
                  {v.description ? (
373
                    <p className="serif mt-2 text-lg text-bone">{v.description}</p>
374
                  ) : null}
375
                </div>
376
                {v.linkUrl ? (
377
                  <a
378
                    href={v.linkUrl}
379
                    target="_blank"
380
                    rel="noopener noreferrer"
381
                    className="kicker text-gold hover:text-gold-bright"
382
                  >
383
                    Visit →
384
                  </a>
385
                ) : null}
386
              </li>
387
            ))}
388
          </ul>
389
        </section>
390
      ) : null}
391
 
392
      {}
393
      <section className="mt-16 border-t border-[var(--line)] pt-10">
394
        <div className="flex items-end justify-between">
395
          <h2 className="display text-3xl">The Crowd</h2>
396
          <span className="kicker text-ash">
397
            {String(comments.length).padStart(2, "0")} comments
398
          </span>
399
        </div>
400
 
401
        <form action={addComment} className="mt-6 flex flex-col gap-3">
402
          <input
403
            name="body"
404
            required
405
            placeholder="Say something about the set"
406
            className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold"
407
          />
408
          <div className="mt-2">
409
            <button
410
              type="submit"
411
              className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
412
            >
413
              Post comment
414
            </button>
415
          </div>
416
        </form>
417
 
418
        {comments.length === 0 ? (
419
          <p className="serif mt-8 text-xl text-ash">
420
            No comments yet. Be the first to speak on it.
421
          </p>
422
        ) : (
423
          <ul className="mt-8">
424
            {comments.map((c) => (
425
              <li
426
                key={c.id}
427
                className="border-b border-[var(--line)] py-6"
428
              >
429
                <p className="serif text-xl text-bone">{c.body}</p>
430
                <p className="kicker mt-2 text-ash">{stamp(c.createdAt)}</p>
431
              </li>
432
            ))}
433
          </ul>
434
        )}
435
      </section>
436
 
437
      {}
438
      <section className="mt-16 border-t border-[var(--line)] pt-10">
439
        <div className="flex items-end justify-between">
440
          <h2 className="display text-3xl">Report</h2>
441
          <span className="kicker text-ash">Flag for review</span>
442
        </div>
443
        <form action={reportEvent} className="mt-6 flex flex-wrap items-center gap-4">
444
          <input
445
            name="reason"
446
            placeholder="What's wrong with this listing? (optional)"
447
            className="kicker flex-1 border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none placeholder:text-ash focus:border-gold"
448
          />
449
          <button
450
            type="submit"
451
            className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
452
          >
453
            Report
454
          </button>
455
        </form>
456
      </section>
457
      <SiteFooter />
458
    </main>
459
  );
460
}